Skip to content

A second StemDeck no longer adopts the first one's backend - #431

Merged
thcp merged 1 commit into
mainfrom
fix/second-instance-adopts-backend
Aug 24, 2026
Merged

A second StemDeck no longer adopts the first one's backend#431
thcp merged 1 commit into
mainfrom
fix/second-instance-adopts-backend

Conversation

@thcp

@thcp thcp commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes #424.

Launching a second StemDeck while one was already running handed the new window the already-running instance's backend, and with it that instance's data directory and library. Nothing on screen suggested anything was wrong.

Two independent faults had to line up, so both are fixed.

The port reservation probed the wrong address

reserve_port bound 127.0.0.1 while the backend binds 0.0.0.0. On Windows those do not collide, so an occupied port looked free, the fallback to another port never ran, and the backend we spawned died on bind:

ERROR: [Errno 10048] error while attempting to bind on address ('0.0.0.0', 8000)
INFO:  Application shutdown complete.

The reservation now claims the same address the backend will bind, so a taken port reads as taken and the fallback does its job.

It claims the port without listening on it

bind is what reserves an address; listen is what makes a program a server. Reserving with a TcpListener on 0.0.0.0 would make StemDeck.exe itself a server in Windows Firewall's eyes and prompt every user on next launch, on top of the prompt the backend already causes. The shell should not be answering that prompt on the backend's behalf, so the reservation binds only, via socket2 (already in the dependency graph, so no new crate is pulled in; the lock diff is one line).

A test pins the assumption this rests on: a held, non-listening reservation still refuses a real listener, on both Windows and Linux.

The health check accepted any 200

This is what turned a dead child into a silent adoption. The other instance answered instantly while ours was still importing torch, so the very first poll succeeded and the shell pointed the window at a backend that was never ours.

/api/health now reports the answering process, and wait_for_health accepts only the child it spawned. The child is watched while polling too, so a backend that cannot bind its port fails in about a second with a message naming the contended port, instead of after ninety with a stack trace to interpret.

Testing

  • The scenario itself is a test. a_stranger_on_the_port_is_never_accepted_as_our_backend stands up a fake instance answering 200 on the port while the spawned child dies, exactly as reported. It must return an error naming the port.
  • The test was verified to have teeth: with the identity comparison removed, it fails. It is not passing by accident.
  • our_own_backend_is_accepted covers the other direction, so verification cannot be so strict that a healthy start is refused.
  • Rust: 53 tests pass on Windows, 54 on Linux (via WSL, since PR CI does not compile the desktop app). Clippy and rustfmt clean on both.
  • Python: 595 pass, with the same 14 pre-existing Windows-only failures as main. Ruff clean.

Behaviour change worth noting

A second instance now starts its own backend on a different port rather than failing or adopting. Two installs can run side by side, each with its own library, which is what someone launching a second copy expects.

Launching a second StemDeck while one was running gave the new window the
already-running instance's backend, and with it that instance's data
directory and library. Nothing on screen suggested anything was wrong.

Two independent faults had to line up, so both are fixed.

The port reservation probed 127.0.0.1 while the backend binds 0.0.0.0. On
Windows those do not collide, so an occupied port looked free, the
fallback to another port never ran, and the backend we spawned died on
bind with 10048. The reservation now claims the same address the backend
will bind, so a taken port reads as taken.

It claims it without listening. bind is what reserves an address; listen
is what makes a program a server, and a server on 0.0.0.0 is what makes
Windows Firewall interrupt the user. The shell should not be answering
that prompt on the backend's behalf, so the reservation binds only.

The health check accepted any 200 on the port. That is what turned a dead
child into a silent adoption: the other instance answered instantly while
ours was still starting. /api/health now reports the answering process,
and only the child we spawned is accepted. The child is watched while
polling too, so a backend that cannot bind fails in a second with a
message naming the contended port, rather than after ninety with a stack
trace.

Verified by removing the identity check and confirming the scenario test
fails without it.
@thcp
thcp merged commit d52fdb9 into main Aug 24, 2026
12 checks passed
@thcp
thcp deleted the fix/second-instance-adopts-backend branch August 24, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A second StemDeck instance silently adopts the first one's backend

1 participant